Support u64 vertex ids in the bftree provider - #1216
Conversation
There was a problem hiding this comment.
Pull request overview
This PR generalizes the diskann-bftree provider to support u64 vertex IDs (while keeping u32 as the default), enabling bf-tree indexes to exceed the u32::MAX vertex ceiling. It threads a new BfTreeId abstraction through the provider, neighbor storage, vector starting-point logic, and save/load metadata, and adds tests that exercise the wide-id path end-to-end.
Changes:
- Introduces
BfTreeId(u32/u64) and adds capacity validation to prevent silent truncation when minting IDs from indices. - Makes
BfTreeProviderand related accessors/strategies generic over vertex-id typeI, including neighbor record sizing viasize_of::<I>(). - Persists
id_widthinSavedParamsand validates width/capacity on load; adds tests foru64high-bit IDs andu64save/load round-trips.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| diskann-bftree/src/id.rs | Adds BfTreeId trait + capacity validation helper for index-derived IDs. |
| diskann-bftree/src/lib.rs | Exposes the new id module and re-exports BfTreeId. |
| diskann-bftree/src/vectors.rs | Makes starting-point ID generation generic over BfTreeId. |
| diskann-bftree/src/neighbors.rs | Sizes bf-tree neighbor records based on I width; adds u64 ID collision/round-trip test. |
| diskann-bftree/src/provider.rs | Makes BfTreeProvider generic over I; adds save/load id_width validation and u64 path tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1216 +/- ##
==========================================
+ Coverage 90.67% 90.68% +0.01%
==========================================
Files 514 515 +1
Lines 98764 99220 +456
==========================================
+ Hits 89554 89981 +427
- Misses 9210 9239 +29
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
|
Does u64 bloat adjacency list, and if so, is there a chance we can bring in a delta compression ? |
@harsha-simhadri yes, in u64 mode it doubles the adjacency-list storage (neighbor ids are stored inline), but that's explicit opt-in and there are no u64 consumers yet. I can tackle delta compression as a follow-up. Neighbor order doesn't affect search correctness, so we can sort ids and delta+varint them, which recovers essentially all of the u64 overhead. |
thanks, could you create an issue for future follow up. thanks |
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
The follow up issue is already linked above your comment. |
|
@harsha-simhadri |
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
3583acb to
5d4d540
Compare
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
5d4d540 to
b07f493
Compare
01ea329 to
9217da1
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
diskann-bftree/src/provider.rs:1666
- The load-time id-width mismatch error message is grammatically awkward for 8-byte ids ("a 8-byte") and could be clearer by avoiding the article and repeating "vertex ids" for both widths.
return Err(ANNError::log_index_error(format!(
"index was built with {}-byte vertex ids but is being loaded with a {expected}-byte \
id type; load it with the matching id type",
saved_params.id_width,
)));
diskann-bftree/src/provider.rs:3655
- This test constructs
max_pointsasu32::MAX as usize + 2, which will overflowusizeon 32-bit targets (wrapping in release or panicking in debug) and makes the test invalid there. Gate the test to 64-bit targets (where the intended capacity value is representable).
async fn test_new_rejects_capacity_exceeding_id_type() {
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
9217da1 to
ebf3dbc
Compare
The provider hardcoded `u32` vertex ids, capping any bf-tree-backed index at ~4.29B vectors. That ceiling is at odds with the crate's purpose of supporting larger-than-memory, billion-scale-and-beyond datasets. Introduce a `BfTreeId` trait (`VectorId + IntoUsize` plus index-construction) implemented for `u32` and `u64`, and thread a new id type parameter `I` through `BfTreeProvider<T, Q, I = u32>` and all of its accessors, strategies, and save/load impls. The default `I = u32` keeps every existing caller (benchmark harness, tests) compiling and behaving identically, while `u64` is now available opt-in for indexes that exceed the 32-bit range. `iter()`/`IntoIterator` now map `0..total` through `I::from_index` instead of relying on `Range<u32>` (a generic `Range<I>` would require the unstable `Step` trait). `VectorProvider::starting_points` is likewise generic over the id type. The on-disk neighbor format follows the id width and is self- consistent on load (the width is not persisted, mirroring the metric). A new `test_quantized_index_search_u64_ids` builds and searches a `BfTreeProvider<_, _, u64>` index end-to-end to prove the u64 path is functional, not merely compilable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reinforce the newly-reachable u64 id path surfaced by review: - neighbors: size record key/value buffers by size_of::<I>() instead of hardcoded size_of::<u32>(), so u64 providers reserve correct record sizes. - id: add validate_id_capacity to reject vertex counts that overflow the id type, and guard new_empty with it. - provider: persist id_width in SavedParams (back-compat default of 4) and validate it on load, so a u64 index can't be silently loaded as u32. - tests: add u64 high-bit id coverage (ids/neighbors > u32::MAX, low-bit collision check) plus a u64 save/load round-trip, id-width mismatch rejection, and capacity-guard rejection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…w guards Address review feedback on the u64 bftree provider (PR #1216): - Replace the `IntoUsize` supertrait bound with a first-class `BfTreeId::as_index` method, so the id abstraction owns its index arithmetic instead of leaning on a generic conversion. - Bake dense id iteration into `BfTreeId::id_range`, returning a concrete `IdRange<I>` that maps `from_index` inside `next()`. This keeps the conversion a zero-sized, inlinable fn item rather than the stored `fn(usize) -> I` pointer that `Range::map` folded in (which defeated inlining and forced an indirect call per neighbor id), while preserving the exact-size and double-ended properties of the range. - Guard the `max_points + num_start_points` and `max_points + frozen_points` sums with `checked_add`, returning an `ANNError` instead of risking a `usize` overflow. Kept the `VectorId` supertrait: the core `HasId::Id` and `DataProvider::InternalId` associated types both require it, so dropping it would only force `I: VectorId` to be restated at every impl site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add two tests exercising bf-tree vertex ids beyond u32::MAX: - test_quantized_index_search_u64_high_ids: end-to-end quantized insert + search with ids above u32::MAX, using a sparse max_points so no billion-scale allocation is needed. - test_u64_id_recall_parity_on_transposed_dataset: builds a random dataset twice (dense u32 identity vs order-preserving u64 offset above u32::MAX) and asserts identical neighbor sets per query plus a recall floor against brute-force ground truth. Existing u64 tests only used the u64 type with values in 0..N, which still fit in u32 and would pass even under silent 32-bit truncation. Also add a BfTreeId::INDEX_CONVERSION_LOSSLESS associated const, overridden for u64 to assert a 64-bit usize and referenced from the monomorphized validate_id_capacity. This turns the as_index truncation into a compile error only for u64 providers on 32-bit targets, leaving the default u32 path (even on 32-bit) unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: d3795cf4-fd2c-46db-ac06-fc4a360392ba
ebf3dbc to
ab2a1b0
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
diskann-bftree/src/vectors.rs:105
- The new error message for start-point id overflow no longer indicates which id type failed or what limit was exceeded, which makes debugging harder (especially now that ids can be
u32oru64). Include the concrete id type and its width in the message.
.map(|i| {
I::try_from_index(i).ok_or_else(|| {
ANNError::message(lazy_format!(
move,
"start point id {i} exceeds the id type's maximum"
What
Make the bf-tree provider generic over its vertex-id type so it can index past
the
u32::MAX(~4.3B) ceiling, while keepingu32as the default (no memory orstorage cost for existing datasets). Also add defensive hardening, wide-id test
coverage, and a benchmark-layer path to validate the
u64id space againstreal-world datasets.
id.rs—BfTreeIdtrait withu32/u64impls,from_index/try_from_index, andvalidate_id_capacity.provider.rs—BfTreeProvider<T, Q = QuantVectorProvider, I = u32>.The vertex id type threads through construction, save, and load. The persisted
SavedParamsrecordsid_width; load validates it against the requested typeso a
u32index can't be silently reopened asu64(or vice versa).neighbors.rs/vectors.rs— neighbor cells and starting-point seedingare generic over
I, sized viasize_of::<I>().Why
Larger-than-memory, billion-scale datasets need vertex ids wider than
u32.Defaulting
I = u32keeps the common case unchanged; opting intou64is asingle type parameter at the call site.
Hardening
Defensive coverage for the wide-id path:
id_widthmismatch instead of misreading data.> u32::MAX) ids and their low-bit collisions, plus au64save/load round-trip.Wide-id tests + 32-bit compile guard
The earlier
u64-typed tests only used values in0..N, which still fit inu32and would pass even under silent 32-bit truncation. Added tests thatactually exceed
u32::MAX:test_quantized_index_search_u64_high_ids— end-to-end quantized insert +search with ids above
u32::MAX, using a sparsemax_pointsso nobillion-scale allocation is needed.
test_u64_id_recall_parity_on_transposed_dataset— builds a random datasettwice (dense
u32identity vs an order-preservingu64offset aboveu32::MAX) and asserts identical per-query neighbor sets plus a recall flooragainst brute-force ground truth.
Added
BfTreeId::INDEX_CONVERSION_LOSSLESS, overridden foru64to assert a64-bit
usizeand referenced from the monomorphizedvalidate_id_capacity.This turns
as_indextruncation into a compile error only foru64providerson 32-bit targets, leaving the default
u32path (even on 32-bit) unaffected.Real-world
u64validation (benchmark layer)Real datasets ship as a dense
0..Nblock whose ids always fit inu32, sothey never exercise the
u64paths, and reaching an id> u32::MAXpositionally would need billions of rows. Added an opt-in, validation-only
benchmark path that transposes a dataset's dense
u32row ids into theu64-only range (base > u32::MAX) without touching production code:diskann-benchmark-core: anOffsetToId<u64>mapper (base + i, checkedadd), documented as validation-only.
diskann-benchmark: asingle_or_multi_insert_offsethelper, aU64IdValidation { offset }JSON config onBfTreeFullPrecisionBuild(
#[serde(default)], non-breaking), a widenedbftree_parameters_offset, anda dedicated
run_u64_id_validationorchestration that builds aBfTreeProvider<T, NoStore, u64>, inserts with the offset, and widensground-truth ids via
Matrix::mapto keep recall aligned. This reuses thealready-generic provider/search/recall stacks rather than relaxing the shared
u32build pipeline.u64run reproduces the identity-u32recall baseline.Validated on the 100K Wikipedia/Cohere dataset (offset
2^32, so every vertexid lives above
u32::MAX): recall@100 0.9536 (u32) vs 0.9537 (u64) — parityconfirmed (the sub-
1e-4delta is nondeterministic 8-thread multi-insert batchordering, not an id-path difference).
Deferred: the streaming benchmark is not yet transposable — its shared
executor is
u32-pinned (theTagSlotManagerslot allocator, theManagedStreamtrait, theManagedtranslation layer, andBfTreeStreamallhardcode
u32), so a transposed stream run needs a broader genericization ofthat shared infrastructure. Tracked as follow-up.
Notes
Iis an internal id. Theinmem2direction uses
InternalId = u32(hardcoded) + generic external id. Worthaligning on whether bftree needs wide internal ids or should follow the same
internal-u32 / external-generic split before merge.